Skip to content

Added async versions of many more redis calls, full test suite, ci intergration, tests both redis and dragonfly php 7.2-8.5#13

Open
detain wants to merge 69 commits into
workerman-php:masterfrom
detain:master
Open

Added async versions of many more redis calls, full test suite, ci intergration, tests both redis and dragonfly php 7.2-8.5#13
detain wants to merge 69 commits into
workerman-php:masterfrom
detain:master

Conversation

@detain

@detain detain commented May 31, 2026

Copy link
Copy Markdown

Changelog

All notable changes to this fork of workerman/redis
are documented here.

The format is based on Keep a Changelog,
and the project aims to follow Semantic Versioning.

This fork (detain/redis) diverged from upstream at the Update Redis.php
commit (49627c1). Everything below is new in the fork — upstream changes
(SSL support, Workerman v5 support, the reconnect/auth-db fix) predate the fork
point and are not repeated here.

The headline of the fork is a complete, typed, Dragonfly-targeted command
surface
: every command Dragonfly fully or
partially supports now has an @method declaration for IDE/PHPStan, an
integration test, and — wherever the generic __call() route was broken — a
real explicit implementation. Both execution modes (callback and Revolt
coroutine) are supported throughout.


[Unreleased] — Dragonfly-complete command surface

Summary

Area What changed
Protocol RESP decoder rewritten to parse arbitrarily nested arrays (was flat-only); depth-bounded against stack exhaustion.
SCAN family scan/hScan/sScan/zScan were throwing stubs — now fully implemented, each with a loop-driving *All() iterator helper.
Broken __call() paths fixed No-arg-plus-callback commands (ping, info, quit, …), underscore verbs (SORT_RO, EVAL_RO, …), dotted module verbs (JSON.*, BF.*, …), and rawCommand all got explicit methods that route correctly.
New command coverage ~140 commands documented/implemented across Strings, Keys, Hashes, Lists, Sets, Sorted Sets, Streams, Pub/Sub, Bitmap, Geo, Scripting, Server admin, and the JSON / Bloom / CMS / TopK / RediSearch modules.
Pub/Sub Sharded pub/sub (sPublish/sSubscribe), the full unsubscribe/pUnsubscribe/sUnsubscribe teardown family, and monitor() streaming.
Tooling PHPUnit test harness (unit + subprocess-based integration), PHPStan with baseline, GitHub Actions CI on PHP 7.2/7.3/7.4/8.1/8.2/8.3/8.5 × {Dragonfly, Redis}, Codecov + Codacy coverage.
Test/coverage build-out Suite runs against both engines430 tests (145 unit + 285 feature), Dragonfly 3-skipped / Redis 0-skipped — via Workerman subprocess-coverage merge; ~93% merged line coverage (Client.php 92.44%, Protocols/Redis.php 100%) behind a CI-enforced coverage floor of 90.
Requirements require stays php: >=7.2 and workerman: ^4.1.0 || ^5.0.0 (== upstream); the PHP 7.2/7.3/7.4 CI legs continuously prove the >=7.2 floor.

Changed — Test framework (Pest → PHPUnit) + PHP 7.x CI floor

Replaced Pest with PHPUnit and added PHP 7.2/7.3/7.4 CI legs that actually run
the converted suite, so the advertised php: ">=7.2" floor is continuously
proven. No loss of tests or coverage.

  • Pest → PHPUnit. All 43 test files (8 unit, 35 feature) converted to
    global-namespace final classes; it() closures became test_* methods and
    every Pest matcher was mapped to its PHPUnit assertion (operand order flipped).
    Per-file reflection helpers and the runInWorker() subprocess heredoc bodies
    were preserved verbatim. 430 tests / ~1150 assertions, no silent drops;
    merged line coverage holds at 93.09% (floor 90). Pest, tests/Pest.php,
    and the unused mockery/mockery dev dep were removed; the global test helpers
    moved to tests/helpers.php.
  • Cross-version dev tooling (ranges, not pins). require-dev now declares
    phpunit/phpunit: "^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.0". Each PHP version
    resolves a compatible runner: 7.2 → PHPUnit 8.5, 7.3/7.4 → 9.6,
    8.1+ → 10–12. On the 7.x legs CI strips phpstan/phpstan (needs ≥7.4) and
    revolt/event-loop (needs ≥8.1) before composer update, so Workerman
    resolves to v4 there. A second config phpunit9.xml.dist (testsuites + env
    only, no <coverage>) is used by the 7.x legs and validates under PHPUnit
    8.5 and 9 alike.
  • PHP 7.2 test-suite compatibility. Downconverted 17 arrow functions
    (fn () =>, PHP 7.4+) to closures; rewrote the ProtocolTest
    ConnectionInterface stub to 7.1-safe signatures (mixed/bool|null
    untyped params / ?bool) that stay LSP-compatible with both Workerman v4
    (untyped) and v5 (typed); and routed the skipOnBackend()/skipTest()
    helpers through Assert::markTestSkipped() (the PHPUnit-10+
    SkippedWithMessageException class does not exist in PHPUnit 8.5/9).
  • CI matrix is now {7.2, 7.3, 7.4, 8.1, 8.2, 8.3, 8.5} × {Dragonfly, Redis}. PHPStan runs on the 8.x legs only; coverage + the floor gate run on
    exactly one leg (8.3 + Dragonfly). composer.lock is not committed (it's a
    library; the legs need different resolutions), so CI uses composer update.
    Coroutine-mode tests self-skip below PHP 8.1 via the coroutineSupported()
    guard. Pinned actions bumped (checkout@v6, cache@v5) and
    FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 set to clear the Node 20 deprecation
    warnings.

Added — Commands by family

Strings

getDel, getEx, substr documented (@method + tests). All routed through
__call() already; the declarations expose them to IDE autocomplete and PHPStan.

Keys

  • copy, touch, expireTime, pExpireTime documented.
  • scan / scanAll — see SCAN family below.

Hashes

  • hRandField documented.
  • hScan / hScanAll — see SCAN family.
  • HEXPIRE family documented (@method only, via __call): hExpire,
    hPersist, hExpireAt, hTtl, hExpireTime, hPExpire, hPExpireAt,
    hPTtl, hPExpireTime. (Dragonfly: partial — currently supports
    HEXPIRE/HTTL; the tests accept either a real per-field integer-array reply
    or an -ERR unknown command, so they start asserting real values
    automatically as Dragonfly catches up.)

Lists

lMove, lMPop, lPos, blMove, blMPop documented.

Sets

  • sMIsMember, sInterCard documented.
  • sScan / sScanAll — see SCAN family.

Sorted Sets

zRandMember, zMScore, zDiff, zDiffStore, zInter, zInterCard,
zUnion, zRangeStore, zMPop, bzMPop, zRevRangeByLex, zRemRangeByLex,
zLexCount documented. Plus zScan / zScanAll (see SCAN family).

Streams

  • xAutoClaim, xSetId documented.
  • xAdd() — new explicit method (encoder fix). The RESP encoder flattens a
    nested array argument by emitting its values only, so a
    ['field' => 'value'] message passed to XADD through __call() lost the
    field names and the server rejected it. xAdd($key, $id, $message, $maxLen = 0, $approximate = false, $cb = null) flattens the message itself so the natural
    field-map shape works. Signature mirrors phpredis; MAXLEN [~] n is emitted
    before the id; an empty message throws InvalidArgumentException.

Bitmap

  • bitOp, bitPos, bitField documented (route cleanly through __call).
  • bitFieldRo — explicit underscore-bridge method (wire verb
    BITFIELD_RO; __call's strtoupper would have produced BITFIELDRO).

Geo

  • geoSearch documented.
  • geoRadiusRo, geoRadiusByMemberRo — explicit underscore-bridge methods
    (GEORADIUS_RO, GEORADIUSBYMEMBER_RO).

Scripting

  • evalRo, evalShaRo — explicit underscore-bridge methods (EVAL_RO,
    EVALSHA_RO).

Pub/Sub

  • sPublish documented; sSubscribe() explicit (mirrors
    pSubscribe(); process() now flips the subscribe-lock on SSUBSCRIBE too).
  • unsubscribe() / pUnsubscribe() / sUnsubscribe() — explicit, with a
    lock bypass. A subscribed connection refuses queued commands, so these write
    the teardown frame straight to the socket via $_connection->send(), then a
    new handleUnsubscribeAck() clears $_subscribe, drops the stale SUBSCRIBE
    entry, and drains anything queued while locked. The optional trailing callback
    fires (true, $client) once the connection is fully back in normal mode (held
    until the last channel drops on a partial unsubscribe). Calling when not
    subscribed is a no-op that still invokes the callback.

Connection / server

  • ping, info, dbSize, time, flushDb, flushAll — explicit, fixes
    the no-arg-plus-callback bug (see Fixed). flushDb/flushAll take an
    optional ASYNC boolean.
  • quit — explicit, with don't-reconnect semantics (a new $_quitting flag
    the onClose handler honours, skipping the 5s reconnect timer).
  • echo, hello documented; hello() is explicit so hello($cb) folds the
    closure into the $cb slot.

Server administration

  • Subcommand dispatchers (thin wrappers over dispatcher()):
    config(), acl(), slowLog(), memory(), command(), cluster().
  • Explicit lifecycle verbs: lastSave(), save(), role(),
    bgSave($schedule = false, …), digest() (Dragonfly extension),
    shutdown($mode = 'SAVE', …) (sets $_quitting so the socket teardown
    doesn't trigger a reconnect)
    .
  • monitor($cb) — streams every command the server processes. Long-lived
    like subscribe(), but with its own $_monitoring lock (there is no
    UNMONITOR; stop it by close()ing the client). The opening +OK handshake
    is swallowed; each later call is one raw monitor line. A re-entry guard ignores
    monitor() on an already-streaming connection.
  • replicaOf, slaveOf, debug, delEx (Dragonfly extension) documented.

JSON module (RedisJSON-compatible, native in Dragonfly)

  • json(...$args) dispatcher (JSON. prefix).
  • 16 typed shortcuts: jsonSet, jsonMSet, jsonMerge, jsonGet,
    jsonMGet, jsonType, jsonObjKeys, jsonObjLen, jsonArrLen,
    jsonStrLen, jsonDel, jsonForget, jsonArrAppend, jsonNumIncrBy,
    jsonStrAppend, jsonToggle.

Bloom Filter / Count-Min Sketch / TopK modules (RedisBloom-compatible)

  • Dispatchers: bf() (BF.), cms() (CMS.), topk() (TOPK.).
  • Bloom Filter (5): bfReserve, bfAdd, bfExists, bfMAdd, bfMExists.
  • Count-Min Sketch (6): cmsInitByDim, cmsInitByProb, cmsIncrBy,
    cmsQuery, cmsMerge (optional WEIGHTS clause), cmsInfo.
  • TopK (7): topkReserve, topkAdd, topkIncrBy, topkQuery,
    topkCount, topkList, topkInfo.

RediSearch / FT module (preloaded in Dragonfly)

  • ft(...$args) dispatcher (FT. prefix) + 11 typed shortcuts:
    ftCreate, ftSearch, ftAggregate, ftDropIndex (optional DD),
    ftInfo, ftList (FT._LIST), ftAlter, ftConfig, ftTagVals,
    ftSynDump, ftSynUpdate, ftProfile.

Modules introspection

  • module(...$args) dispatcher + moduleList() (MODULE LIST).
    MODULE LOAD is wired but docs-only — Dragonfly's modules are static.

Read-only / underscore-verb bridges

  • sortRo() — explicit, emits SORT_RO (matches the bitFieldRo /
    geoRadiusRo / evalRo pattern). Mirrors sort()'s option grammar.
  • rawCommand(...$args) — explicit escape hatch (see Fixed).

Added — SCAN family (was throwing stubs)

scan, hScan, sScan, zScan previously throw new Exception('Not implemented'). All four are now real, each with a loop-driving *All()
iterator helper that supports both callback and Revolt coroutine modes:

Single-call Iterator Reply reshaped to Notes
scan($cursor, $opts, $cb) scanAll($opts, $cb) ['cursor' => …, 'keys' => […]] may yield duplicate key names across the keyspace (documented caller responsibility)
hScan($key, $cursor, $opts, $cb) hScanAll($key, $opts, $cb) ['cursor' => …, 'fields' => assoc] duplicate fields overwrite (unique by definition)
sScan($key, $cursor, $opts, $cb) sScanAll($key, $opts, $cb) ['cursor' => …, 'members' => […]] sScanAll dedupes via a string-keyed map (defeats PHP numeric-string coercion)
zScan($key, $cursor, $opts, $cb) zScanAll($key, $opts, $cb) ['cursor' => …, 'members' => member=>score] scores kept as raw strings to preserve precision
  • Options (MATCH, COUNT, TYPE for scan) are case-insensitive;
    unknown keys are silently ignored.
  • *All() accepts a 'limit' option (default 100000) so a growing keyspace
    can't loop forever.
  • On a Redis-side error the callback receives false (matches the client's
    error convention).

Added — Tooling & infrastructure

  • Pest test harness with separate Unit and Feature suites.
    • Unit: ProtocolTest (RESP encode/decode round-trips, no server needed),
      MethodSurfaceTest (reflection guards for methods that can't run live —
      shutdown, monitor, the unsubscribe family).
    • Feature: a subprocess-based integration harnessrunInWorker($snippet)
      proc_opens a short-lived PHP child running the snippet inside a Workerman
      worker with $redis, $emit($value), $fail($msg) in scope, returning the
      result over fd 3 (stdout carries Workerman's boot banner). Tests skip
      cleanly when no Redis/Dragonfly is reachable at REDIS_URL
      (default redis://127.0.0.1:6379). 198 tests / 620 assertions, all
      passing against a live Dragonfly.
  • PHPStan at level 5 with a baseline (phpstan-baseline.neon) snapshotting
    pre-existing legacy typing issues so new commits can't regress past that line.
    The baseline shrank from 44 → 9 entries as the refactors fixed typing nits.
  • GitHub Actions CI (.github/workflows/ci.yml): Pest + PHPStan on PHP 8.1,
    8.2, 8.3 against a live Dragonfly (installed via APT / Docker image), with
    Composer caching, Codecov upload (8.3 leg only), and a separate Codacy
    coverage-reporter job.
  • composer.json: description/keywords/authors filled in;
    require-dev (Pest, Mockery, PHPStan); suggest revolt/event-loop;
    Tests\\ autoload-dev; analyze / test / test:coverage scripts.
  • README: badges (CI, Codecov, Codacy grade + coverage, Packagist, license,
    PHP version), coverage-graph visualizations, and usage sections for every new
    surface.

Test infrastructure — subprocess coverage merge + dual-backend

  • Subprocess coverage merge. Feature tests execute each assertion inside a
    proc_opened Workerman worker child, so pcov in the parent PHPUnit process
    never instrumented src/Client.php — it reported a false 0.0%. The worker
    (tests/Support/run-in-worker.php) now collects coverage inside the child
    (gated on a COVERAGE_DIR env) and dumps a unique cov-<uniq>.cov;
    bin/merge-coverage.php merges every .cov (Feature children + the in-process
    Unit unit.cov) into coverage.xml (Clover) plus a text summary, and
    bin/run-coverage.sh orchestrates the whole run. composer test:coverage now
    runs sh bin/run-coverage.sh. With the merge in place Client.php shows its
    real ~66.5% (was a misleading 0.0%); total line coverage is ~68.6%
    (up from a reported 7.6%).
  • Dual-backend testing (CI + local). The full suite now runs against both
    Dragonfly and Redis
    . CI (.github/workflows/ci.yml) gained a
    backend: [dragonfly, redis] matrix axis crossed with php: [8.1, 8.2, 8.3]
    (fail-fast off); each leg starts exactly one engine on 127.0.0.1:6379 — the
    Dragonfly image, or redis/redis-stack-server:latest on the Redis leg so the
    JSON/Bloom/CMS/TopK/FT modules are present. Coverage is collected on the single
    php=8.3 && backend=dragonfly leg. Locally, make test-dragonfly /
    make test-redis / make test-all / make coverage (plus scripts/start-redis.sh
    and scripts/start-dragonfly.sh) drive Dragonfly on :6379 and Redis on
    :63790.
  • Coverage floor gate. bin/merge-coverage.php accepts --min=<pct> /
    COVERAGE_MIN and exits non-zero (code 3) below the floor. Initial floor is
    65 (set in bin/run-coverage.sh, overridable via COVERAGE_MIN), to be
    ratcheted toward 95 in later groups. This is the canonical gate — CI fails below it.
  • Backend-aware skip helpers. Free functions currentBackend() and
    skipOnBackend($backend, $reason) in tests/Pest.php (and runInWorker()
    forwarding REDIS_BACKEND to the child) let an engine-specific case skip with
    a logged reason
    — every skip prints [<backend>] <reason>; no silent skips.
    Current results: Dragonfly 201 passed / 0 skipped; Redis 196 passed /
    5 skipped
    (the 5 are the RediSearch FT family in tests/Feature/FtSearchTest.php
    — see Compatibility in the README).

Protocol coverage — Protocols/Redis.php to 100%

  • Added ~23 in-process unit tests (tests/Unit/ProtocolTest.php) for the RESP
    codec, taking src/Protocols/Redis.php from ~90% to 100% line + method
    coverage. They cover the input()/measure() frame-length probe (every
    branch, including the MAX_DEPTH sentinel and the null bulk/array fast paths),
    incomplete-frame handling (returns 0 = "need more bytes"), and the
    decode()/decodeOne() edge branches: binary-safe bulk strings with embedded
    CRLF / null bytes, large multi-KB bulks, negative integers, the protocol-error
    tuple for unknown/empty/no-CRLF input, and depth-exceeded propagation. All are
    server-free (no backend required). Total merged line coverage rose to 69.48%
    and the coverage floor was ratcheted to 69.

Client pure-logic coverage — in-process unit tests

  • Added 34 in-process unit tests (tests/Unit/ClientCommandShapingTest.php,
    ClientScanAllTest.php, ClientUnsubscribeAckTest.php) that drive
    src/Client.php's pure command-shaping and aggregation logic without the
    Workerman event loop or a live server
    — using
    ReflectionClass::newInstanceWithoutConstructor() so commands queue (rather
    than send) and the queued wire arrays can be asserted. Covers: __call
    trailing-callable popping (incl. the lone-callable footgun and the
    randomKey/multi/exec/discard exception list), dispatcher dot-glue vs
    space-split, rawCommand verbatim + empty-args \InvalidArgumentException,
    select/auth argument shaping, error(), the scanAll/hScanAll/sScanAll/
    zScanAll callback-mode aggregation (cursor termination, multi-page
    accumulation, LIMIT cap, error abort, MATCH/COUNT/TYPE forwarding, set dedup,
    zScanAll score-string precision), and handleUnsubscribeAck lock bookkeeping.
    Client.php merged coverage 66.5% → 68.5%; total merged 69.5% → 71.3%;
    coverage floor ratcheted to 70. (The Revolt coroutine-mode branches of
    *ScanAll/suspenstion() remain for the Revolt group.)

Connection / lifecycle coverage — Feature tests

  • Added tests/Feature/ConnectionLifecycleTest.php (7 cases) covering connection
    verbs not exercised elsewhere: auth no-password error path, auth not
    poisoning _auth after a rejected credential, select to a valid DB (tracks
    _db) and to an out-of-range index (error reply, no state advance),
    closeConnection() / close() teardown (connection nulled, queue emptied),
    and a hello(2, …) handshake that pins the reply map (server/proto/role/
    version) rather than just asserting array shape. The auth cases gate on the
    observed reply (skip when the server accepts AUTH with no password set, as
    Dragonfly does) so the file is correct under any invocation. Added a skipTest()
    free helper in tests/Pest.php for behaviour-gated (non-backend-name) skips.
    Client.php merged 68.5% → 70.4%; total merged 71.3% → 73.0%.

Data-type command sweep — Feature tests

  • Added 57 Feature cases across tests/Feature/KeyspaceCommandsTest.php (13),
    StringsCountersTest.php (13), ListSetZsetExtraTest.php (19) and
    HashStreamExtraTest.php (13), covering the classic data-type and keyspace
    verbs not already exercised by ModernCommandsTest/StringsKeysExtraTest/the
    SCAN-family tests:
    • Keyspace: type, rename/renameNx, persist, expire/pExpire,
      exists (multi + repeat), unlink, keys, randomKey, dump+restore
      (binary cross-key round-trip), object ENCODING/REFCOUNT, move.
    • Strings/counters: append, strLen, setRange/getRange, getSet,
      incrBy/decrBy/incrByFloat, setEx/pSetEx/setNx, setBit/getBit,
      mSet/mGet, mSetNx.
    • Lists/sets/zsets: the classic lPushlTrim/rPopLPush,
      sAddsDiffStore, zAddzPopMin/zPopMax families.
    • Hashes/streams: hSethStrLen, xAdd/xLen/xRange/xRevRange/
      xRead/xDel/xTrim.
      Assertions pin real values (counts, members, scores-as-strings, hGetAll/
      hMGet maps, dump→restore value equality). One backend-gated skip
      (OBJECT is unknown on Dragonfly; the test runs on Redis). Client.php merged
      70.4% → 72.95%; total merged 73.0% → 75.34%.

Module command coverage + FT un-gating — Feature tests

  • Added tests/Feature/FtModuleTest.php (5 cases) for the six RediSearch verbs
    not previously asserted: ftAlter, ftConfig, ftTagVals, ftSynUpdate +
    ftSynDump (synonym round-trip), and ftProfile (asserts the embedded search
    result). The JSON/Bloom/CMS/TopK families were already fully covered at the
    shortcut level (JsonTest/BloomFilterTest/CmsTest/TopkTest) — not
    duplicated.
  • Removed the 5 stale skipOnBackend('redis', …) gates in
    tests/Feature/FtSearchTest.php.
    They were defending against an
    FT.SEARCH SEARCH_INDEX_NOT_FOUND divergence on an earlier Redis build that no
    longer reproduces on Redis 8.8 + RediSearch 80800 — verified that FT.CREATE /
    SEARCH / AGGREGATE / INFO / CONFIG all work, and confirmed stable across three
    consecutive make test-redis runs. The FT family is now exercised on both
    engines, and the Redis leg has zero skips (was 5).
    Client.php merged 72.95% → 75.26%; total merged 75.34% → 77.45%.

Pub/Sub delivery coverage — Feature tests

  • Added tests/Feature/PubSubDeliveryTest.php (8 cases) for the plain pub/sub
    delivery paths not previously covered (the existing tests covered the sharded
    family, unsubscribe lock-clearing, and monitor): subscribepublish message
    delivery (channel + payload pinned), pSubscribe pattern delivery (pattern +
    channel + payload), publish receiver count (0 with no subscriber, ≥1
    with one), pubSub('NUMSUB', …) and pubSub('NUMPAT') introspection,
    multi-channel subscribe([...]) delivery, and a negative test proving a message
    is NOT delivered after unsubscribe. Every streaming test is bounded — the 2nd
    client publishes from a Timer only after the subscribe ack, the message
    callback $emits once, and a non-recurring Timer $fails before the harness
    timeout — verified flake-free across 5 consecutive runs per engine.
    Client.php merged 75.26% → 75.79%; total merged 77.45% → 77.93%.

Command-surface completeness + error-path coverage — Feature tests

  • Added tests/Feature/SurfaceCompletenessTest.php (16 cases) covering
    @method-declared verbs with no prior assertion — bitCount,
    blPop/brPop/bRPopLPush and bzPopMax/bzPopMin (exercised on
    pre-populated keys so the blocking path returns immediately, never hangs),
    zRangeByLex/zRevRangeByScore/zRemRangeByRank/zRemRangeByScore/
    zinterstore/zunionstore, the HyperLogLog pfAdd/pfCount/pfMerge, the geo
    geoDist/geoHash/geoPos, watch/unwatch, and the stream consumer-group
    xAck/xClaim/xInfo/xPending. Assertions pin real values (bit counts,
    popped members, cardinalities, distances, geohashes, pending/acked counts).
  • Added tests/Feature/ErrorRepliesTest.php (6 cases) asserting the client's
    error-delivery contract end-to-end: WRONGTYPE, unknown command, wrong arg count,
    value-not-integer, and syntax-error replies all arrive as $reply === false
    with a non-empty $client->error() (keyword-checked, wording-tolerant across
    engines), plus that error() resets to '' after a subsequent successful
    command. This covers the onMessage error branch and the error() getter.
  • This group adds command-surface and error-contract assertion coverage rather
    than new Client.php lines (the new verbs route through the already-covered
    queueCommandencodeonMessage path), so the merged number holds at
    ~77.9%. The Redis leg stays at zero skips.

Revolt coroutine-mode coverage — Feature tests

  • The client's coroutine path — when no callback is passed and Revolt\EventLoop
    is loaded, queueCommand() suspends the current fiber and RETURNS the reply
    synchronously (via suspenstion() + onMessage resume) — was previously
    untested (every existing test runs callback mode). Added revolt/event-loop as
    a dev dependency and tests/Support/run-in-worker-coroutine.php, a worker that
    boots Workerman on its Revolt-backed Workerman\Events\Fiber driver so
    onWorkerStart runs inside a fiber and callback-less commands return their
    replies directly. Exposed via a new runInCoroutineWorker() helper in
    tests/Pest.php (the shared proc_open logic is factored into
    runInWorkerScript(); runInWorker() behaviour is unchanged).
  • Added tests/Feature/CoroutineModeTest.php (4 cases): synchronous set/get/
    incr/del returns; scanAll returning the full key set synchronously; the
    hScanAll/sScanAll/zScanAll coroutine aggregation loops; and the
    queueCommand guard that throws when a coroutine-mode command is issued while
    the connection is subscribe/monitor-locked (a fiber that could never resume).
    This exercises the suspend/resume branch and all four coroutine *ScanAll
    helpers. Safety: the existing callback worker references no Revolt/EventLoop
    symbols, so class_exists(EventLoop::class, false) stays false there and the
    callback suite is unaffected. Client.php merged 75.79% → 81.16%; total
    merged 77.93% → 82.82%.

Coverage close-out — remaining reachable branches

  • Added in-process unit + targeted feature tests covering the genuinely-reachable
    Client.php branches the integration suite couldn't hit cheaply:
    tests/Unit/ClientShapingTier9Test.php (34 cases — the set/incr/decr
    second-form overloads → SETEX/INCRBY/DECRBY, sort/sortRo option flattening,
    xAdd empty-message guard + MAXLEN shaping, dotted-dispatcher trailing-callable
    pops, formatter early-returns, shutdown _quitting flag),
    tests/Unit/ClientSubscribeDispatchTest.php (12 cases — the
    subscribe/pSubscribe/sSubscribe message/pmessage/smessage forwarding arms, the
    error-bail and unknown-type diagnostic arms, the unsubscribe-ack teardown, and
    the second-stream assertNoActiveStream throw), and
    tests/Feature/ReconnectPrependTest.php (1 case — the dead-port connection
    failure reported through the connection callback). Client.php merged
    81.16% → 92.32% (methods 65.85% → 91.06%); total merged
    82.82% → 92.99%, and the coverage floor was ratcheted to 90.
  • The residual ~7% of Client.php is genuinely impractical to cover without
    socket fault injection — connection/socket failure paths, the onClose
    immediate-vs-delayed auto-reconnect timing, onMessage exception re-throw +
    reconnect-on-!, diagnostic echo new Exception sinks, and the coroutine
    error/LIMIT-cap arms of the *ScanAll loops (whose logic is already proven in
    callback mode). These are enumerated line-by-line in docs/TEST_COVERAGE_PLAN.md
    under Coverage close-out (Group 9).

Fixed

  • Broken phpredis-compat @method stubs → real local accessors. Eleven
    @method declarations (getHost, getPort, getDbNum, getAuth,
    getTimeout, getReadTimeout, isConnected, getLastError,
    clearLastError, getPersistentID, and getMultiple) had no implementation
    and no __call mapping, so calling them sent the uppercased verb (GETHOST,
    ISCONNECTED, GETMULTIPLE, …) to the server, which both engines reject as
    unknown commands. They are now real public methods: the accessors return
    client state synchronously (getHost/getPort parse _address;
    getDbNum/getAuth mirror _db/_auth; getTimeout/getReadTimeout read
    the connect_timeout/wait_timeout options the client actually uses;
    isConnected checks the connection status null-safely; getLastError returns
    null when clean, clearLastError resets it; getPersistentID is null
    this async client has no persistent connections), and getMultiple() is a real
    MGET alias routed through queueCommand() (works in both callback and
    coroutine modes). Covered by tests/Unit/ClientAccessorsTest.php (20 cases)
    and tests/Feature/GetMultipleTest.php.

  • Malformed @method PHPDoc (named param after a variadic). rawCommand's
    @method read (...$commandAndArgs, $cb = null) — invalid, and it made PHPStan
    see a 2-arg cap on a fully-variadic method. Fixed to (...$commandAndArgs), and
    the same drop-the-trailing-$cb fix was applied to every other @method line
    with a parameter after the variadic.

  • Test-harness temp-file leak. The Feature-test subprocess runners
    (tests/Support/run-in-worker.php and run-in-worker-coroutine.php) left a
    wm-redis-test-*.{pid,log} pair per invocation in the system temp dir — the
    bottom-of-file cleanup never ran because Workerman exits first, so a full suite
    run leaked tens of thousands of files. The pid/log files are now scoped to a
    dedicated wm-redis-tests/ subdir and removed via a register_shutdown_function
    plus an in-handler unlink before each child exit(), with a start-of-run
    containment sweep in bin/run-coverage.sh. A full run now leaves zero residue.

  • Nested-array RESP replies. The decoder (src/Protocols/Redis.php) was
    flat-only and could not parse multi-bulk replies like SCAN's
    [cursor, [keys]]. Rewritten into recursive measure() / decodeOne()
    helpers that walk any RESP type at any depth, bounded by MAX_DEPTH = 64
    (deeper replies surface as a protocol error rather than blowing PHP's stack).
    Null bulks ($-1) and null arrays (*-1) now detect via
    $offset === strpos(...) instead of 0 === strpos(...), so they decode
    correctly when nested (the old form only matched at buffer offset 0,
    breaking nested nils inside MGET-style replies). All flat-reply contracts
    preserved.

  • No-arg-plus-callback commands silently broke. __call() only extracts a
    trailing callable when count($args) > 1 (or for a tiny allowlist), so
    $redis->ping($cb) shipped the closure to the server as a PING argument.
    Fixed for ping, info, dbSize, time, flushDb, flushAll, quit,
    hello, lastSave, save, role, bgSave, digest, shutdown, monitor
    via explicit methods (rather than touching __call(), where
    is_callable('phpinfo')-style false positives would corrupt single-string
    commands).

  • rawCommand always failed. It was @method-declared but unbacked, so it
    fell through __call(), which uppercased the method name and prepended it —
    $redis->rawCommand('GET', 'k') went on the wire as ['RAWCOMMAND','GET','k']
    and every server returned -ERR unknown command 'RAWCOMMAND'. Now an explicit
    method forwards args verbatim and pops a trailing callable as the callback;
    throws InvalidArgumentException when no command parts remain.

  • Underscore-verb commands unreachable via __call(). strtoupper on a
    camelCase name drops the underscore (bitFieldRoBITFIELDRO). Bridged
    with explicit methods: bitFieldRo, geoRadiusRo, geoRadiusByMemberRo,
    evalRo, evalShaRo, sortRo.

  • Dotted module verbs uncallable in PHP. JSON.SET, BF.ADD, etc. can't be
    method names. Solved with the json()/bf()/cms()/topk()/ft()
    dispatchers and typed shortcuts.

  • xAdd field names dropped — see Streams above.

  • Wait-timeout timer permanently deleted while streaming. The constructor's
    timeout timer used to delete itself during a subscribe/monitor stream, so
    after the stream ended (a monitor rejection, an unsubscribe) queued commands
    lost their timeout guard for the life of the connection. It now skips while
    streaming and resumes afterward.

Async hardening (full-source review pass)

  • Wait-timeout scan leaked its timer — the client could never be GC'd. The
    constructor's Timer::add(1, …) handle was never stored, so close() could
    not delete it: the timer kept firing forever, and because its closure captures
    $this the client object stayed pinned in memory (defeating the
    gc_collect_cycles() in close()). In a worker that creates clients
    dynamically this leaked one object + one timer per client. The handle is now
    kept in the (previously unused) $_waitTimeoutTimer property and torn down in
    close().
  • Coroutine command on a subscribe/monitor-locked connection hung the fiber.
    In Revolt mode queueCommand() suspends the current fiber until the reply
    arrives — but process() refuses to send anything while the connection is in
    subscribe/monitor mode, so the reply (and the resume) could never come while
    the lock held. That was a silent, unrecoverable hang. It now throws a clear
    Workerman\Redis\Exception instead of suspending.
  • A second subscribe() / pSubscribe() / sSubscribe() was silently
    dropped.
    This client pins one stream entry at the head of the queue and
    routes every message to that entry's callback; a second subscribe while one is
    active or pending can't reach the wire (the lock) and its messages would go to
    the first callback anyway. It now throws rather than failing silently. The
    guard inspects both the live flags and the queue, so it also catches
    back-to-back calls issued before the first frame is sent (when the flags are
    still false). monitor() keeps its documented silent-ignore contract but
    reuses the same active-or-pending detection.
  • select() / auth() cached state on a failed reply. Their format
    callbacks ran regardless of success, so a rejected SELECT/AUTH still
    updated $_db/$_auth — which the next reconnect would then replay. They now
    mutate only when the reply was not an error.
  • Wait-timeout false positives around blocking commands. The scan only
    exempted BLPOP/BRPOP, so a long-blocking BRPOPLPUSH/BLMOVE/BLMPOP/
    BZPOPMIN/BZPOPMAX/BZMPOP at the head could trip a reconnect, and commands
    queued behind any blocker were failed with a spurious "Wait Timeout" despite
    never having been sent. A BLOCKING_COMMANDS set now covers the full family,
    and when the head is a blocking command the scan returns early — nothing behind
    it is timed out.
  • Callback exceptions caught \Exception, not \Throwable. An \Error
    (e.g. TypeError) thrown from a user callback escaped onMessage() before
    process() could pump the next command, wedging the queue. Widened to
    catch (\Throwable …) (still re-thrown after the pump runs).

Changed

  • Requirement: PHP >=7>=8.1 (Pest 3+/4 needs it).
  • Internal refactor — queueCommand() + dispatcher() helpers. Every
    explicit command method previously repeated the same ~10-line block
    (Revolt-suspension check, queue push, process(), conditional suspend()).
    queueCommand(array $args, $cb, $format) collapses it to a single
    return per method, and __call() routes through it too — so the
    callback-vs-coroutine decision lives in exactly one place.
    dispatcher(string $prefix, array $args) is the multi-verb / dotted-module
    counterpart ('CLUSTER '['CLUSTER','INFO',…]; 'JSON.'
    ['JSON.SET',…]). Net −76 lines despite adding both helpers.

Known limitations / partial support

These are documented and dispatched, but Dragonfly may not implement every
option (the PHPDoc and tests note it): SORT_RO, COPY, the HEXPIRE family,
CLIENT KILL / CLIENT TRACKING, BGSAVE, several ACL write verbs,
MODULE LOAD, BF.RESERVE, and the FT.* search surface across the board.

Commands Dragonfly does not support are intentionally not added (e.g.
LCS, MIGRATE, OBJECT ENCODING, WAIT, FUNCTION/FCALL, SWAPDB,
Cuckoo Filter, Graph, TimeSeries, T-Digest, cluster write ops). See
async_plan.md for the full inclusion/exclusion rationale.


Upstream baseline

Everything prior to fork point 49627c1 is upstream workerman/redis
(latest tag v2.0.5), including SSL support, Workerman v5 support, and the
post-reconnect auth-db fix. See the upstream repository for its history.

detain added 30 commits May 28, 2026 12:14
Introduces a development testing and static analysis setup so the fork
can run quality gates locally and in CI before adding new commands.

Composer changes:
  - Bump PHP requirement to >=8.1 (required by Pest 3+/4)
  - Add require-dev: pestphp/pest, mockery/mockery, phpstan/phpstan
  - Add suggest: revolt/event-loop (enables coroutine return mode)
  - Add Tests\\ PSR-4 autoload-dev mapping
  - Add scripts: analyze (phpstan), test (pest), test:coverage (pest --coverage --min=70)
  - Allow the pest-plugin composer plugin

Configuration files:
  - phpstan.neon.dist at level 5 with baseline include
  - phpstan-baseline.neon snapshotting 44 pre-existing typing issues
    in legacy code so new commits cannot regress past this line
  - phpunit.xml.dist with separate Unit and Feature test suites,
    coverage source pointing at src/, REDIS_URL env default
  - tests/Pest.php binding closures to Tests\TestCase across both suites
  - tests/TestCase.php with redisUrl() + skipWithoutRedis() helpers so
    integration tests degrade gracefully when no Redis is reachable
  - tests/Unit/ProtocolTest.php with 9 round-trip assertions on the
    RESP encoder + decoder (no live server required)

.gitignore additions for vendor/, composer.lock, coverage artifacts,
phpstan cache, and local-only Caliber/plan files.

Verified: vendor/bin/pest reports 9 passed / 0 failed, and
vendor/bin/phpstan analyse reports OK.
…elpers

Every explicit command method in Client.php previously repeated the same
~10 lines: check class_exists(Revolt\EventLoop), grab a Suspension if
needed, push the queue tuple, call process(), suspend if needed, return
null. Each copy was a maintenance hazard — bug fixes had to land in N
places, and adding new commands meant pasting the boilerplate again.

queueCommand(array $args, ?callable $cb, ?callable $format) collapses
the pattern. Every command method now reduces to one return statement.
__call() also routes through it, so the Revolt-vs-callback decision
lives in exactly one place.

dispatcher(string $prefix, array $args) is the corresponding helper for
multi-verb command families and dotted module commands. Two forms:
  - 'CLUSTER ' (trailing space)  -> emits ['CLUSTER','INFO',...]
  - 'JSON.'    (trailing dot)    -> emits ['JSON.SET',...]
The verb is auto-uppercased and any trailing callable in $args is
popped as the callback. This is the foundation for the upcoming
config(), acl(), slowLog(), memory(), command(), cluster(), json(),
bf(), cms(), topk(), and ft() methods.

Refactored to use queueCommand(): select(), auth(), set(), incr(),
decr(), sort(), mapCb(), keyMapCb(), hMGet(), hGetAll(), __call().
Net delta is -76 lines despite adding two helpers and their docblocks.

Also corrected the @param annotations on select() and auth() to say
'callable|null $cb' instead of 'null $cb' (the legacy form told PHPStan
$cb was strictly null, which made $cb ?: ... look like dead code).
Regenerated phpstan-baseline.neon — 36 errors instead of 44, since the
refactor naturally fixed eight typing nits that fell out with the dead
copies.

Verified: vendor/bin/pest still passes 9/9 (RESP encoder/decoder
suite), vendor/bin/phpstan analyse reports OK.
Workerman's Worker::runAll() takes over the process — forks, installs
signal handlers, prints a banner on stdout, and eventually exits — which
makes it impossible to run multiple async-client commands inline in a
single Pest process. The pragmatic answer is to push each integration
assertion into its own short-lived PHP child.

tests/RedisTestCase.php defines the runInWorker(string $snippet) helper.
It proc_open's a subprocess running tests/Support/run-in-worker.php with
the snippet on stdin and an extra pipe (fd 3) for the test result. The
snippet runs inside a Workerman worker with $redis, $emit($value), and
$fail($msg) in scope. Calling $emit() writes 'OK <json>\n' to fd 3 and
SIGTERM's the master so the whole subprocess tears down cleanly.

Design notes:
  - fd 3 is used instead of stdout because Workerman prints its boot
    banner on stdout and mixing it with the result protocol is fragile.
  - Each invocation sets a unique Worker::$pidFile/$logFile under
    sys_get_temp_dir() so repeat runs don't collide on the "already
    running" check.
  - The child calls posix_kill(getppid(), SIGTERM) + exit(0) after
    flushing the result — Worker::stopAll() alone leaves the master
    monitoring a child it will immediately try to respawn, leading to
    a tight emit loop.

Pest binds Feature/ closures to RedisTestCase via tests/Pest.php, so a
Feature test reads like:

    it('does X', function () {
        $result = $this->runInWorker(<<<'PHP'
            $redis->set('k','v');
            $redis->get('k', function ($v) use ($emit) { $emit($v); });
        PHP);
        expect($result)->toBe('v');
    });

tests/Feature/SmokeTest.php exercises the harness with a SET/GET round
trip and an __call dispatch through incr(). Each test completes in
under 200ms; the proc_open overhead is acceptable for an integration
suite that will grow to ~80 commands.

Verified: vendor/bin/pest reports 11 passed / 17 assertions (9 unit +
2 integration). vendor/bin/phpstan analyse reports OK.

Ignores tests/Support/*.log and *.pid to keep Workerman runtime
artifacts out of git.
CI runs Pest + PHPStan against PHP 8.1, 8.2, and 8.3 with a live
Dragonfly instance installed via the official APT repo. Dragonfly is
wire-compatible with Redis and is this fork's canonical compatibility
target — driving the per-command priority list in the implementation
plan.

Workflow shape:
  - shivammathur/setup-php@v2 brings up PHP with pcntl/posix/sockets/
    json extensions and PCOV for coverage.
  - APT recipe installs Dragonfly and waits up to 30 seconds for
    redis-cli ping to return PONG before continuing.
  - Composer cache is keyed off PHP version + composer.json hash to
    cut cold-cache runs roughly in half.
  - composer analyze (PHPStan) and pest --coverage --coverage-clover
    run sequentially; the clover file is uploaded as an artifact only
    on the 8.3 leg to avoid duplicate uploads.
  - codecov/codecov-action@v4 and codacy/codacy-coverage-reporter-action@v1
    consume CODECOV_TOKEN and CODACY_API_TOKEN repo secrets. Codacy
    upload is best-effort (continue-on-error) — Codecov is the canonical
    coverage source.

README badges added: CI status, Codecov coverage, Codacy grade,
Codacy coverage, Packagist version, Packagist downloads, license, and
PHP version constraint. Also added a Development section with the
composer scripts and a note about Dragonfly being the compatibility
target.
…redis.. added stopping and removing redis package to front of workflow.. if its related to startijng it twice added an echo to resolve that
SCAN was the highest-priority entry in the command-coverage backlog —
every non-trivial cache needs key iteration, and the previous
implementation just `throw new Exception('Not implemented')`. Adding it
exposed a deeper problem: the RESP decoder couldn't parse nested-array
replies at all. The fix touches both layers.

Protocol layer (src/Protocols/Redis.php)
  - Replaced the flat input()/decode() with recursive helpers
    measure()/decodeOne() that walk any RESP type at any nesting depth.
  - Added MAX_DEPTH = 64 to bound recursion; deeper replies surface as
    a protocol error rather than blowing PHP's stack — important now
    that the parser can be fed arbitrary array shapes by any server.
  - Null bulks ($-1) and null arrays (*-1) detect via $offset === strpos
    instead of 0 === strpos, so they decode correctly when nested
    (the old `0 ===` only matched at buffer offset 0, breaking nested
    nils inside MGET-style replies).
  - All existing flat-reply contracts preserved.

Client layer (src/Client.php)
  - scan($cursor, array $options = [], $cb = null) replaces the stub.
    MATCH/COUNT/TYPE options are case-insensitive; unknown keys are
    silently ignored. Format callback reshapes Redis's
    [cursor, [keys]] tuple into ['cursor' => string, 'keys' => array]
    so callers don't have to remember the order.
  - scanAll(array $options = [], $cb = null) drives the cursor loop,
    aggregates keys, supports both callback and Revolt coroutine modes,
    and accepts a 'limit' cap (default 100000) so a growing keyspace
    can't loop forever. On Redis-side error the user callback receives
    false (matches the rest of the client's error convention).
  - HSCAN/SSCAN/ZSCAN still throw 'Not implemented' — separate commits.

Tests
  - tests/Feature/ScanTest.php: 12 integration tests covering happy
    path, COUNT/TYPE filters, empty keyspace, cursor pass-through,
    case-insensitive option keys, unknown-key tolerance, malformed
    cursor error path, scanAll aggregation, and the limit boundary.
  - tests/Unit/ProtocolTest.php: 7 new unit tests for the decoder
    rewrite — nested null bulk, nested null array, deeply-nested
    array within MAX_DEPTH, depth-overflow protocol-error path,
    truncated-frame input() returns 0, empty array reply, empty-string
    bulk encoding.

Docs
  - README.md gained a ## SCAN section with both single-call and
    iterator examples plus a note about the limit cap and error
    behavior.

phpstan-baseline.neon shrank by 35 entries (44 -> 9) because the
protocol rewrite naturally fixed several typing issues in the old
decoder.

Verified: vendor/bin/pest reports 31 passed / 191 assertions,
vendor/bin/phpstan analyse reports OK.
Restructures the Codacy upload per the action's recommended pattern: a
separate codacy-coverage-reporter job that needs: test, downloads the
coverage-clover artifact, and runs codacy/codacy-coverage-reporter-action
@v1.3.0 with api-token (the new name; the old project-token alias is
deprecated). The previous in-line step inside the matrix job was firing
before the artifact had any chance to be consumed elsewhere and used
the old parameter name.

Also drops the duplicate Codecov badge — the tokenized graph badge added
on the previous push already shows the same data, and two badges side by
side under the same name was noise.

Codecov upload stays as a step inside the test matrix (uploads only on
the 8.3 leg) since it doesn't benefit from being a separate job — the
codecov-action handles its own token-driven auth in a single network
call.
PHP 8.1's PHPStan flagged `/** @var \Tests\RedisTestCase $this */` inside
Pest test closures as "Variable $this in PHPDoc tag @var does not match
assigned variable $result" — 28 errors on the 8.1 leg of the CI matrix.
Newer PHP/PHPStan combinations accept the annotation; 8.1 does not.

Refactoring runInWorker() from a method on RedisTestCase to a free
function in tests/Pest.php side-steps the issue entirely: test closures
now call the helper as `runInWorker(...)` without any `$this->` ceremony,
so PHPStan has nothing to resolve via type-hint trickery.

RedisTestCase keeps its single remaining responsibility — skipping the
Feature suite via skipWithoutRedis() in setUp() — so feature tests still
degrade gracefully on hosts without Redis.

All 31 Pest tests still pass locally (PHP 8.3); PHPStan still reports OK.
Expecting the 8.1 CI leg to go green on the next run.
Pest 4 + PCOV + PHP 8.1 throws Pest\Exceptions\ShouldNotHappen
("Coverage not found in path: vendor/pestphp/pest/.temp/coverage.php")
when tests fork subprocess children — which every integration test
does via runInWorker(). The coverage merge step can't find the
per-fork coverage temp file and aborts the whole run.

We only ever uploaded the 8.3 coverage to Codecov/Codacy anyway, so
running with --coverage on the other matrix legs was pure waste. This
splits the Pest step into two: a plain `pest` invocation on 8.1/8.2
and the existing `pest --coverage --coverage-clover` on 8.3. The PHP
setup also drops PCOV on the non-8.3 legs since there's nothing for
it to capture.
HSCAN is the non-blocking analogue to HGETALL for large hashes — same
SCAN cursor semantics, scoped to a single key's fields. Replaces the
throwing stub at the bottom of Client.php with a real implementation
plus an iterator helper.

Client::hScan($key, $cursor, array $options = [], $cb = null)
  - Wire: HSCAN <key> <cursor> [MATCH pat] [COUNT n]. Case-insensitive
    option keys. Unknown keys silently ignored.
  - Format callback reshapes Redis's flat field/value pair list
    [cursor, [f1,v1,f2,v2,...]] into the more usable
    ['cursor' => string, 'fields' => assoc] — matching how hGetAll()
    handles its identical reply shape (Client.php:836). Non-array
    replies (errors) pass through unchanged.

Client::hScanAll($key, array $options = [], $cb = null)
  - Drives the cursor loop, aggregates all field=>value pairs into one
    assoc array, halts at cursor '0'.
  - 'limit' option caps total fields collected (default 100000) so an
    unbounded growing hash can't loop forever.
  - On Redis-side error: callback receives false; coroutine mode
    returns false. Mirrors scanAll()'s error contract.
  - Supports both callback and Revolt coroutine modes — same gating
    pattern as scanAll().

Duplicate field handling: hScanAll's accumulator silently overwrites
on collision, which is the correct semantic for hashes since SCAN can
revisit during a rehash but field names are unique by definition.
(Different from scanAll, which can yield duplicate key NAMES across
the keyspace — that's a documented caller responsibility.)

Tests (tests/Feature/HScanTest.php — 7 integration tests)
  - cursor+fields tuple round-trip
  - MATCH filter
  - COUNT hint accepted
  - hScanAll iterates a 150-field hash exactly once
  - 'limit' overshoot bounded (<= limit + COUNT batch)
  - empty hash returns cursor '0' + empty fields
  - malformed cursor surfaces as false to the callback

Drive-by: corrected the throwing-stub docblock summaries for sScan()
and zScan() — they both said 'hScan' from an earlier copy-paste. The
stubs still throw 'Not implemented'; those commands ship in their own
commits.

README gained a ## HSCAN section after ## SCAN.

Verified: vendor/bin/pest reports 38 passed / 218 assertions,
vendor/bin/phpstan analyse reports OK.
SSCAN is the non-blocking analogue to SMEMBERS for large sets. Replaces
the throwing stub at the bottom of Client.php with a real
implementation plus an iterator helper. Pattern mirrors SCAN's flat
member list (vs HSCAN's field/value pairs).

Client::sScan($key, $cursor, array $options = [], $cb = null)
  - Wire: SSCAN <key> <cursor> [MATCH pat] [COUNT n]. Case-insensitive
    option keys; unknown keys silently ignored.
  - Format callback reshapes [cursor, [m1, m2, ...]] into
    ['cursor' => string, 'members' => array]. Non-array replies (errors)
    pass through unchanged.

Client::sScanAll($key, array $options = [], $cb = null)
  - Drives the cursor loop, halts at cursor '0'.
  - Dedupes via a member-keyed map (string-cast keys to defeat PHP's
    numeric-string-to-int coercion so "1" and 1 don't collide). SCAN
    can revisit members during a rehash; this guarantees uniqueness
    in the return value without forcing callers to array_unique() —
    matches set semantics (members are unique by definition).
  - Returns array_values($map) to give callers a numerically indexed
    array of distinct members.
  - 'limit' option caps total members collected (default 100000).
  - On Redis error: callback receives false; coroutine mode returns
    false. Mirrors scanAll/hScanAll error contract.
  - Supports callback + Revolt coroutine modes.

Tests (tests/Feature/SScanTest.php — 7 integration tests)
  - cursor + members tuple round-trip
  - MATCH glob filter
  - COUNT hint accepted
  - sScanAll iterates a 150-member set exactly once with no duplicates
  - 'limit' overshoot bounded (<= limit + COUNT batch)
  - empty / missing key returns cursor '0' + members []
  - malformed cursor surfaces as false to the callback

zScan() still throws — separate commit.

Verified: vendor/bin/pest reports 45 passed / 246 assertions,
vendor/bin/phpstan analyse reports OK.
ZSCAN closes out the SCAN family. Replaces the last throwing 'Not
implemented' stub in Client.php with a real implementation plus an
iterator helper. After this commit, every SCAN variant the client
exposes (SCAN, HSCAN, SSCAN, ZSCAN) has a working primitive and a
loop-aggregating counterpart.

Client::zScan($key, $cursor, array $options = [], $cb = null)
  - Wire: ZSCAN <key> <cursor> [MATCH pat] [COUNT n]. Case-insensitive
    option keys; unknown keys silently ignored.
  - Reshapes [cursor, [m1, s1, m2, s2, ...]] into
    ['cursor' => string, 'members' => ['m1' => 's1', 'm2' => 's2', ...]]
    — assoc with member as key, score AS STRING as value. Scores are
    kept as the raw bulk-string Redis returned rather than cast to
    float, so caller code that needs full precision (e.g. comparing
    against the exact ZADD input or persisting to another store) gets
    the lossless representation. Cast at the call site only when a
    numeric comparison is actually needed.

Client::zScanAll($key, array $options = [], $cb = null)
  - Drives the cursor loop, aggregates all member=>score pairs into
    one assoc array, halts at cursor '0'.
  - 'limit' caps total members (default 100000).
  - On Redis error: callback receives false; coroutine mode returns
    false. Mirrors scanAll/hScanAll/sScanAll.
  - Supports callback + Revolt coroutine modes.

Duplicate handling: members are unique in a sorted set by definition,
so a member re-yielded during a SCAN rehash just overwrites its
score in the accumulator — the correct semantic, no dedupe tracker
needed (different from sScanAll's string-keyed map which guarded
against PHP's numeric-string-to-int coercion on flat lists).

Tests (tests/Feature/ZScanTest.php — 8 integration tests)
  - cursor + member=>score round-trip
  - MATCH glob filter
  - COUNT hint accepted
  - zScanAll iterates a 150-member set exactly once
  - 'limit' overshoot bounded (<= limit + COUNT batch)
  - empty / missing key returns cursor '0' + members []
  - malformed cursor surfaces as false
  - score precision preserved as string ('1.5' round-trips exactly)

No more 'throw new Exception("Not implemented")' anywhere in
src/Client.php — verified with grep.

Verified: vendor/bin/pest reports 53 passed / 284 assertions,
vendor/bin/phpstan analyse reports OK.
The class has carried @method static mixed rawCommand(...$commandAndArgs,
$cb = null) since the original walkor implementation, but no explicit
method body backed it. Calls fell through to __call(), which strtoupper's
the method NAME and prepends it to the args — so $redis->rawCommand('GET',
'k') went on the wire as ['RAWCOMMAND', 'GET', 'k'], producing -ERR
unknown command 'RAWCOMMAND' from every Redis server in existence.

The fix is to intercept the call before __call() ever runs. The new
explicit rawCommand(...$args) pops a trailing callable as the callback
(matching __call()'s convention) and forwards the remaining args
verbatim through queueCommand() — no method-name prepend, no format
reshaping. Throws InvalidArgumentException when no command parts remain
(guards against rawCommand() or rawCommand($cb) with no command).

This re-establishes the documented contract: rawCommand is the escape
hatch for commands not yet wrapped in a dedicated method — new
Dragonfly features, custom modules, anything the wire format supports
that the typed surface hasn't caught up with.

Tests (tests/Feature/RawCommandTest.php — 5 integration tests)
  - arbitrary SET/GET round-trip via rawCommand
  - no-arg command (PING) returns PONG via the callback
  - binary-safe value containing \r\n round-trips byte-exact
  - unknown command surfaces as false to the callback
  - no-command-arg invocation throws InvalidArgumentException

Known sharp edge: is_callable() returns true for plain strings naming
globally-resolvable functions, so rawCommand('PING', 'time') would
treat 'time' as the callback. This matches the existing convention in
__call() and the dispatcher() helper — keeping it consistent rather
than diverging in just one place.

README gained a ## rawCommand section after ## ZSCAN.

Verified: vendor/bin/pest reports 58 passed / 297 assertions,
vendor/bin/phpstan analyse reports OK.
…ck bug

Client::__call() only extracts a trailing callable from $args when
count($args) > 1 OR the method is in the small ['randomKey','multi',
'exec','discard'] allowlist. Every no-arg-payload Redis command called
with just a callback — $redis->ping($cb), ping fails the count check
and isn't in the allowlist, so the closure goes on the wire as a PING
argument and Redis returns "wrong number of arguments". The bug
silently swallows callbacks for some of the most common operations.

Fixing __call() itself is risky: PHP's is_callable() returns true for
string function names ('phpinfo', 'system', etc.), so naively
extracting "last callable arg" in __call() would corrupt legitimate
single-string Redis commands. The safer fix is explicit methods that
intercept these specific calls before __call() ever runs.

This commit adds six such methods, all funnelling through queueCommand():

  ping($cb = null) - returns 'PONG' on success
  info($section = null, $cb = null) - optional section filter
  dbSize($cb = null) - integer count of keys in current DB
  time($cb = null) - [seconds, microseconds] array
  flushDb($async = false, $cb = null) - sync or FLUSHDB ASYNC
  flushAll($async = false, $cb = null) - sync or FLUSHALL ASYNC

info() and the two flush methods follow set()'s pattern: when the
first positional arg is callable, it's treated as the callback (so
$redis->info($cb) works without the section filter). flushDb/All
accept ASYNC via a boolean — defaults to synchronous to match
expectations of casual callers.

Tests (tests/Feature/ServerCommandsTest.php — 8 integration tests)
  - ping returns 'PONG' (the canonical fix proof)
  - info returns a non-empty server banner (accepts redis_version OR
    dragonfly_version so the suite works against either backend)
  - info with section filter returns scoped output
  - dbSize counts keys correctly
  - time returns two numeric strings
  - flushDb synchronous empties the current DB
  - flushDb ASYNC also empties it (with the ASYNC keyword on the wire)
  - flushAll empties all DBs

Flush tests SELECT a high index (14) before flushing so they can't
poison fixtures in DB 0. Each runInWorker subprocess gets its own
client, so the SELECT is naturally isolated.

A new @method block under "Connection / server methods" carries the
six declarations for IDE autocomplete.

README gained a ## Server commands section.

Verified: vendor/bin/pest reports 66 passed / 313 assertions,
vendor/bin/phpstan analyse reports OK.
Reformatted coverage graphs section into a table for better readability.
…ELLO

These nine commands already worked through Client::__call() because each
takes more than a single wire arg, so __call()'s count($args) > 1
callback-extraction branch picks up the trailing closure. The only thing
missing was the @method declaration that exposes them to IDE autocomplete
and PHPStan, plus integration tests that confirm the wire shape end to
end against a live server.

The exception is HELLO. With no required positional args,
$redis->hello($cb) puts count($args) at 1, falls into __call() without
extracting the trailing closure, and serializes the callback as a HELLO
argument — the RESP encoder then calls strlen() on a Closure object and
the subprocess crashes silently. Same shape as the PING/INFO/DBSIZE
no-arg-callback bug fixed in commit 3acd82f.

The fix mirrors info(): an explicit Client::hello() with a callable-as-
first-arg shortcut so hello($cb) folds the closure into the $cb slot
before __call() ever sees it. Sub-commands (AUTH user pass, SETNAME) can
be passed as a flat array which the RESP encoder flattens onto the wire:

  $redis->hello(2, $cb);
  $redis->hello(2, ['AUTH', 'user', 'pass'], $cb);

ECHO doesn't have this bug because the message arg is mandatory; left
as @method-only.

@method declarations added (under matching family headers):
  Strings:     getDel, getEx, substr
  Keys:        copy, touch, expireTime, pExpireTime
  Connection:  echo, hello

Tests (tests/Feature/StringsKeysExtraTest.php — 11 integration tests)
  - getDel returns the value and removes the key
  - getEx without options preserves no-TTL
  - getEx with EX sets a TTL between 1 and 60
  - substr returns a slice
  - copy duplicates a key
  - copy with REPLACE overwrites an existing destination
  - touch counts only existing keys (missing ones don't increment)
  - expireTime returns the absolute unix timestamp
  - pExpireTime returns the absolute millisecond timestamp
  - echo round-trips the message
  - hello returns an array reply (works against Dragonfly's no-arg form)

Verified: vendor/bin/pest reports 77 passed / 336 assertions,
vendor/bin/phpstan analyse reports OK.
QUIT tells the server to close the connection after replying +OK. The
existing client always auto-reconnects on socket close — fine for
unexpected drops, wrong for an intentional QUIT. The fix is a new
$_quitting flag set inside quit() and respected by the onClose handler.

What changed in src/Client.php:

  - New protected $_quitting = false instance var.
  - onClose handler in connect() learns to early-return when $_quitting
    is true, skipping both the immediate connect() retry and the
    5-second _reconnectTimer arming. closeConnection() still runs so
    the underlying socket and timers are cleaned up the same way.
  - New quit($cb = null) method, placed beside ping() in the
    Connection/server group. Sets $_quitting synchronously, then
    enqueues ['QUIT'] via queueCommand(). The user's callback is
    wrapped so the flag is set regardless of whether the caller
    provided one — important because the flag must be visible to
    onClose, which races against the +OK reply.
  - @method declaration added to the Connection/server PHPDoc section.

Without the explicit method, $redis->quit($cb) would hit the no-arg-
callback bug (the closure becomes a QUIT arg) AND the connection
would silently reopen 5 seconds later — both wrong.

Tests (tests/Feature/QuitTest.php — 2 integration tests)
  - quit returns +OK as boolean true (matches the client's
    existing simple-string convention)
  - 500ms after the reply, reflection on the Client shows
    $_quitting === true AND $_connection === null — proving the
    auto-reconnect was suppressed

Tier 1 now complete: SCAN/HSCAN/SSCAN/ZSCAN families, rawCommand,
ping/info/dbSize/time/flushDb/flushAll, the nine @method-documented
commands (getDel/getEx/substr/copy/touch/expireTime/pExpireTime/echo/
hello), and now quit. 79 pest tests passing, PHPStan OK.
These all already work through Client::__call() because each takes two
or more wire args, which the existing count($args) > 1 callback-
extraction branch handles. The only thing missing was the @method
declarations that expose them to IDE autocomplete and PHPStan, plus
integration tests that pin the wire shapes against a live server.

Added @method entries across the existing docblock sections:

  Lists (5):       lMove, lMPop, lPos, blMove, blMPop
  Sets (2):        sMIsMember, sInterCard
  Hashes (1):      hRandField
  Sorted sets (13): zRandMember, zMScore, zDiff, zDiffStore, zInter,
                   zInterCard, zUnion, zRangeStore, zMPop, bzMPop,
                   zRevRangeByLex, zRemRangeByLex, zLexCount
  Streams (2):     xAutoClaim, xSetId

Tests (tests/Feature/ModernCommandsTest.php — 23 integration tests)
  one test per command, each tagged with a unique pest:modern:tN:
  prefix so parallel/repeat runs can't collide.

Implementation notes baked into the tests:

  - Blocking variants (blMove, blMPop, bzMPop) use 100ms timeouts
    against pre-populated structures, so the server returns
    immediately and the suite isn't latency-bound.

  - zMScore scores come back as numeric strings from Dragonfly
    ('1' / '2'), not floats — the test asserts the string form.

  - zUnion/zInter member ordering isn't stable across
    implementations; zUnion sorts before compare, zInter uses a
    single-element intersection to avoid the ordering concern.

  - xSetId requires the new id to be >= the current top id; tests use
    a fixed seed id (1-1) then bump to 999-0.

  - xAutoClaim/xSetId setup uses rawCommand('XADD', ...) instead of
    Client::xAdd() because the existing @method signature for xAdd
    passes the message as ['k'=>'v'] which the RESP encoder flattens
    by value (dropping field names). That's a pre-existing client
    quirk worth its own commit later; for this doc-only round we side-
    stepped via rawCommand.

Verified: vendor/bin/pest reports 102 passed / 379 assertions,
vendor/bin/phpstan analyse reports OK.

Tier 1 complete after 8af9528 (quit); this commit closes out Tier 2.
Sharded pub/sub (Redis 7.0+) keeps publish/subscribe message delivery
local to a single hash slot in clustered deployments — bandwidth and
isolation win at scale. On a standalone Dragonfly the two commands
collapse to PUBLISH/SUBSCRIBE behaviorally, but the wire surface and
client wiring are independent commands and need their own pair of
entry points.

What changed in src/Client.php

  - sPublish: @method declaration only. It takes two wire args
    (channel + message) so __call()'s count($args) > 1 callback
    extraction already routes it correctly.

  - sSubscribe: explicit method placed next to pSubscribe(). Mirrors
    its structure exactly — wraps the user callback in an inner
    closure that pattern-matches the response frame type ('ssubscribe'
    ack vs 'smessage' delivery) and dispatches to the user with the
    same (channel, message, client) signature the existing subscribe()
    uses.

  - process(): extended to flip _subscribe = true on SSUBSCRIBE (not
    just SUBSCRIBE / PSUBSCRIBE). Without this the very next user
    command would race the subscription's first message and corrupt
    the per-connection state machine.

  - @method declaration for sSubscribe added in the Pub/sub section.

UNSUBSCRIBE / PUNSUBSCRIBE / SUNSUBSCRIBE deferred

These interrupt an active subscription. The current client design
locks process() once _subscribe is true, so any routed-via-__call
unsubscribe would silently queue forever — the wire frame never goes
out. A correct implementation needs a subscribe-lock bypass plus a
clean reset path to flip _subscribe back to false on the final
unsubscribe ack. That's its own commit. A TODO comment near
subscribe() in Client.php now flags this so the gap doesn't get lost.

Tests (tests/Feature/PubSubExtraTest.php — 3 integration tests)

  - sPublish to a channel with no subscribers returns 0
  - sSubscribe receives a message published via sPublish: two
    Workerman\Redis\Client instances in the same subprocess event
    loop, one subscribing, the other publishing on a 200ms delay.
    The subscribe callback emits the (channel, message) tuple.
  - pubSub('CHANNELS', 'pattern') regression check — a subscriber
    holds open a channel, the pubSub introspection returns it.

Dragonfly standalone supports SPUBLISH/SSUBSCRIBE as single-shard
variants, so the tests run green against the local server.

Verified: vendor/bin/pest reports 105 passed / 386 assertions,
vendor/bin/phpstan analyse reports OK.
Tier 4 added @method declarations for nine Redis commands across the
Bitmap, Geo, and Scripting families. Five of them ship on the wire with
an underscore in the verb (BITFIELD_RO, GEORADIUS_RO, GEORADIUSBYMEMBER_RO,
EVAL_RO, EVALSHA_RO) but the documented camelCase method names (bitFieldRo,
geoRadiusRo, ...) cannot reach those verbs through __call(), which only
strtoupper()s the method name — the resulting BITFIELDRO has no
underscore and Dragonfly responds with "ERR unknown command".

Bridging the gap: five thin explicit methods funnel the right wire verb
through queueCommand(). Each accepts the established callable-as-extra-
slot shortcut (the same trick info()/flushDb()/hello() use) so callers
can write either form:

  $redis->bitFieldRo('key', 'GET', 'i5', 0, fn ($r) => ...);
  $redis->evalRo('return ARGV[1]', ['x'], 0, fn ($r) => ...);

The four commands that route cleanly through __call (BITOP, BITPOS,
BITFIELD, GEOSEARCH) keep @method-only declarations.

@method declarations added (under family headers):
  Bitmap (4):  bitOp, bitPos, bitField, bitFieldRo
  Geocoding (3): geoSearch, geoRadiusRo, geoRadiusByMemberRo
  Scripting (2): evalRo, evalShaRo

Tests (tests/Feature/BitmapGeoEvalRoTest.php — 9 integration tests)
  - bitOp AND across two bitmaps, asserting the byte-level XOR pattern
  - bitPos finds the first set bit across a zero byte
  - bitField INCRBY with i5 signed counter, two calls = value 2
  - bitFieldRo GET reads the i5 value set above
  - geoSearch FROMLONLAT BYRADIUS returns near-coordinate members
  - geoRadiusRo returns members within a radius
  - geoRadiusByMemberRo returns members within a radius of another
    member (sorted assertion for stability)
  - evalRo literal-return script
  - evalShaRo round-trip through SCRIPT LOAD then evalShaRo

Verified: vendor/bin/pest reports 114 passed / 398 assertions,
vendor/bin/phpstan analyse reports OK.
detain added 30 commits May 29, 2026 00:02
…ng-cmd timeouts

Full-source review pass over the async machinery surfaced six issues, all
fixed here in src/Client.php with regression coverage and updated docs.

Fixes
- Wait-timeout timer leak (the only one that bites in normal operation):
  the constructor's `Timer::add(1, …)` handle was never stored, so close()
  could not delete it. The timer fired forever and, by capturing $this,
  pinned the client in memory — defeating the gc_collect_cycles() in
  close(). Workers that create clients dynamically leaked one object + one
  timer each. The handle now lives in the previously-unused
  $_waitTimeoutTimer property and is torn down in close().

- Coroutine-mode deadlock: in Revolt mode an ordinary command suspends the
  fiber until its reply arrives, but process() won't send while the
  connection is subscribe/monitor-locked — so the reply, and the resume,
  could never come. queueCommand() now throws Workerman\Redis\Exception
  instead of suspending into an unrecoverable hang.

- Silent second subscribe: this client pins one stream entry at the head of
  the queue and routes every message to its callback, so a second
  subscribe()/pSubscribe()/sSubscribe() (or mixing families) could never
  reach the wire and was dropped silently. It now throws. The new
  streamActiveOrPending() detector checks both the live flags and the queue,
  so it also catches back-to-back calls issued before the first frame is
  sent (when the flags are still false). monitor() keeps its documented
  silent-ignore contract but reuses the same detector.

- select()/auth() cached state on a failed reply: their format callbacks ran
  regardless of success, so a rejected SELECT/AUTH still updated
  $_db/$_auth, which the next reconnect would replay. They now mutate only
  when the reply was not an error.

- Wait-timeout false positives around blocking commands: the scan only
  exempted BLPOP/BRPOP, so a long-blocking BRPOPLPUSH/BLMOVE/BLMPOP/
  BZPOPMIN/BZPOPMAX/BZMPOP at the head could trip a reconnect, and commands
  queued behind any blocker were failed with a spurious "Wait Timeout"
  despite never being sent. A BLOCKING_COMMANDS set now covers the full
  family, and the scan returns early when the head is a blocking command.

- Callback exceptions caught \Exception, not \Throwable: an \Error (e.g.
  TypeError) from a user callback escaped onMessage() before process() could
  pump the next command, wedging the queue. Widened to catch \Throwable
  (still re-thrown after the pump runs).

Tests
- tests/Feature/StreamGuardTest.php: a second subscribe throws; mixing
  pSubscribe after subscribe throws; a single subscribe is unaffected and an
  ordinary command still drains after unsubscribe.
- Full suite: 201 passed (623 assertions); PHPStan level 5 clean
  (baseline regenerated for the new $_waitTimeoutTimer Workerman-Timer
  typing entries, matching the existing _connection/_connectionCallback
  pattern).

Docs
- CHANGELOG: new "Async hardening (full-source review pass)" subsection.
- README: documents the one-stream-per-connection rule in Pub/Sub and
  Monitor; Requirements section + composer floor clarify PHP >=7.2 runtime
  for callback mode (coroutine mode and dev tooling still need >=8.1).
Test infrastructure foundation for the coverage build-out.

Coverage measurement:
- Feature tests run each assertion in a proc_open Workerman worker child,
  so pcov in the parent never saw src/Client.php (false 0.0%). The worker
  now collects coverage inside the child (gated on COVERAGE_DIR) and dumps a
  unique cov-<uniq>.cov; bin/merge-coverage.php merges all .cov (Feature
  children + in-process Unit unit.cov) into coverage.xml + a text summary;
  bin/run-coverage.sh orchestrates. composer test:coverage now runs it.
- Real merged baseline: total 68.62% (Client.php 66.53%, Protocols/Redis
  90.22%), up from a misleading 7.6%.
- Coverage floor gate: merge-coverage.php --min / COVERAGE_MIN exits non-zero
  below the floor. Initial floor 65, to ratchet toward 95 in later groups.

Dual-backend testing (Dragonfly + Redis):
- CI matrix php:[8.1,8.2,8.3] x backend:[dragonfly,redis]; each leg starts one
  engine on 6379 (dragonfly image, or redis-stack-server for modules).
  Coverage collected on the 8.3+dragonfly leg only.
- Local: Makefile targets test-dragonfly/test-redis/test-all/coverage/analyze;
  scripts/start-redis.sh + start-dragonfly.sh (idempotent).
- Backend-aware skips: currentBackend()/skipOnBackend() free functions in
  tests/Pest.php; runInWorker() forwards REDIS_BACKEND. No silent skips.
- Fixed MonitorTest/PubSubExtraTest aux clients hardcoding the Dragonfly port
  (they silently tested Dragonfly even on the Redis leg).

Results: Dragonfly 201 passed/0 skipped; Redis 196 passed/5 skipped (FT family,
RediSearch divergence on the local build, logged reasons). PHPStan clean.

Plan and rationale in docs/TEST_COVERAGE_PLAN.md.
Added ~23 server-free unit tests to tests/Unit/ProtocolTest.php, taking
src/Protocols/Redis.php from ~90% to 100% line + method coverage:

- input()/measure() frame-length probe: every branch incl. the MAX_DEPTH
  sentinel, null bulk/array ($-1, *-1) fast paths, empty array, nested SCAN
  frame sizing, and incomplete-frame cases that must return 0 (need more bytes).
- decode()/decodeOne() edge branches: binary-safe bulk with embedded CRLF,
  null byte, multi-KB bulk, negative integer, protocol-error tuple for
  unknown/empty/no-CRLF input, depth-exceeded propagation, and nested
  element-without-CRLF propagation.

Added a protocolStubConnection() helper and refactored the pre-existing
input() test to use it. No src/ changes.

Total merged line coverage 68.62% -> 69.48%; coverage floor ratcheted to 69.
Both backends green (Dragonfly 224, Redis 219 +5 skipped); PHPStan clean.
Added 34 server-free Unit-tier tests driving src/Client.php command-shaping
and aggregation logic without the Workerman event loop, via
ReflectionClass::newInstanceWithoutConstructor() (commands queue instead of
send; queued wire arrays are asserted via reflection on _queue):

- ClientCommandShapingTest.php (20): __call trailing-callable popping incl. the
  lone-callable footgun and the randomKey/multi/exec/discard exception list;
  dispatcher dot-glue vs space-split; rawCommand verbatim + empty-args
  \InvalidArgumentException; select/auth arg shaping; error().
- ClientScanAllTest.php (9): scanAll/hScanAll/sScanAll/zScanAll callback-mode
  aggregation driven through the real stored formatter + step closures —
  cursor-'0' termination, multi-page accumulation, LIMIT cap, error abort,
  MATCH/COUNT/TYPE forwarding, hash overwrite, set dedup, zScanAll score-string
  precision.
- ClientUnsubscribeAckTest.php (5): handleUnsubscribeAck lock-clearing.

Client.php merged 66.53% -> 68.53%; total merged 69.48% -> 71.31%; coverage
floor ratcheted to 70. Both backends green (Dragonfly 258, Redis 253 +5
skipped); PHPStan clean; no src/ changes.

Coroutine-mode *ScanAll / suspenstion() branches remain for the Revolt group.
Added tests/Feature/ConnectionLifecycleTest.php (7 cases) for connection verbs
not covered elsewhere:
- auth no-password error path + auth not poisoning _auth after a rejected
  credential (both gated on the OBSERVED reply: skip when the server accepts
  AUTH with no password set, as Dragonfly does — robust under any invocation,
  not backend-name-based).
- select to a valid DB (tracks _db) and to an out-of-range index (error reply,
  no state advance).
- closeConnection() / close() teardown (connection nulled, queue emptied).
- hello(2, ...) handshake pinning the reply map (server/proto/role/version),
  exercising the previously-unhit $protover arg-shaping branch.

Added a skipTest() free helper in tests/Pest.php for behaviour-gated (non
backend-name) skips, kept PHPStan-clean via SkippedWithMessageException like
skipOnBackend.

Overlap audit confirmed ping/info/dbSize/time/flush*, save/lastSave/role/config/
acl/etc., bgSave, echo, quit are already covered (ServerCommandsTest /
ServerAdminTest / MiscTier9Test / StringsKeysExtraTest / QuitTest) — not
duplicated.

Client.php merged 68.5% -> 70.4%; total merged 71.3% -> 73.03%; floor stays 70.
Both backends green (Dragonfly 263 +2 skipped, Redis 260 +5 skipped); PHPStan
clean; no src/ changes.
Added 57 Feature cases across four files covering classic data-type and
keyspace verbs not already exercised by ModernCommandsTest / StringsKeysExtraTest
/ the SCAN-family tests:

- KeyspaceCommandsTest.php (13): type, rename/renameNx, persist, expire/pExpire,
  exists (multi+repeat), unlink, keys, randomKey, dump+restore (binary cross-key
  round-trip), object ENCODING/REFCOUNT, move.
- StringsCountersTest.php (13): append, strLen, setRange/getRange, getSet,
  incrBy/decrBy/incrByFloat, setEx/pSetEx/setNx, setBit/getBit, mSet/mGet, mSetNx.
- ListSetZsetExtraTest.php (19): classic lPush..lTrim/rPopLPush, sAdd..sDiffStore,
  zAdd..zPopMin/zPopMax families.
- HashStreamExtraTest.php (13): hSet..hStrLen, xAdd/xLen/xRange/xRevRange/xRead/
  xDel/xTrim.

Assertions pin real values (counts, members, scores-as-strings, hGetAll/hMGet
maps, dump->restore value equality). One backend-gated skip (OBJECT unknown on
Dragonfly; test runs on Redis); unique pest:g4:* key prefixes; move test
re-SELECTs db0 and leaves no db1 leak.

Client.php merged 70.4% -> 72.95%; total merged 73.03% -> 75.34%; floor stays 70.
Both backends green (Dragonfly 320 +3 skipped, Redis 318 +5 skipped); PHPStan
clean; no src/ changes.

Note: getMultiple() is a broken @method alias (no impl/__call mapping, sends
literal GETMULTIPLE which both engines reject) — reported for a src fix, no test
written for the broken behaviour.
Added tests/Feature/FtModuleTest.php (5 cases) covering the six RediSearch verbs
not previously asserted: ftAlter, ftConfig, ftTagVals, ftSynUpdate + ftSynDump
(synonym round-trip), ftProfile (asserts the embedded search result). The
JSON/Bloom/CMS/TopK families were already fully covered at the shortcut level
(JsonTest/BloomFilterTest/CmsTest/TopkTest) — not duplicated.

Removed the 5 stale skipOnBackend('redis', ...) gates in FtSearchTest.php. They
defended against an FT.SEARCH SEARCH_INDEX_NOT_FOUND divergence on an earlier
Redis build that no longer reproduces on Redis 8.8 + RediSearch 80800 — verified
FT.CREATE/SEARCH/AGGREGATE/INFO/CONFIG all work, stable across 3 consecutive
make test-redis runs. The FT family now runs on BOTH engines and the Redis leg
has ZERO skips (was 5).

Client.php merged 72.95% -> 75.26%; total merged 75.34% -> 77.45%; floor stays 70.
Dragonfly 325 +3 skipped; Redis 328 +0 skipped (was 318 +5). PHPStan clean; no
src/ changes.
Added tests/Feature/PubSubDeliveryTest.php (8 cases) covering the plain pub/sub
delivery paths not previously tested (existing tests covered the sharded family,
unsubscribe lock-clearing, and monitor):
- subscribe -> publish message delivery (channel + payload pinned)
- pSubscribe pattern delivery (pattern + channel + payload)
- publish receiver count: 0 with no subscriber, >=1 with one
- pubSub NUMSUB and NUMPAT introspection
- multi-channel subscribe([...]) delivery
- negative: message NOT delivered after unsubscribe (real 0.8s wait window)

Every streaming test is bounded: the 2nd client publishes from a Timer only
AFTER the subscribe ack (no register-race), the message callback emits once, and
a non-recurring Timer fails before the harness timeout. Verified flake-free
across 5 consecutive runs per engine (10/10 green, ~4.2s, zero timeouts).

Client.php merged 75.26% -> 75.79%; total merged 77.45% -> 77.93%; floor stays 70.
Dragonfly 333 +3 skipped; Redis 336 +0 skipped. PHPStan clean; no src/ changes.
Added two test files (22 cases) on both engines:

- SurfaceCompletenessTest.php (16): @method verbs with no prior assertion —
  bitCount; blPop/brPop/bRPopLPush + bzPopMax/bzPopMin (on PRE-POPULATED keys so
  the blocking path returns immediately, never hangs; verified no timeout across
  3 runs); zRangeByLex/zRevRangeByScore/zRemRangeByRank/zRemRangeByScore/
  zinterstore/zunionstore; pfAdd/pfCount/pfMerge (HLL); geoDist/geoHash/geoPos;
  watch/unwatch; xAck/xClaim/xInfo/xPending. Assertions pin real values.
- ErrorRepliesTest.php (6): the error-delivery contract end-to-end — WRONGTYPE,
  unknown command, wrong arg count, value-not-integer, syntax error all arrive as
  reply===false + non-empty error() (keyword-checked, wording-tolerant), plus
  error() resets to '' after a later success. Covers the onMessage error branch.

unwatch() is routed via rawCommand('UNWATCH', $cb) because the no-key single-arg
form hits the documented __call footgun (callable not popped) — a test workaround
for known client behaviour, not a bug.

Surface/contract coverage rather than new Client.php lines (new verbs route
through the already-covered queueCommand->encode->onMessage path), so merged
holds at ~77.9%; floor stays 70. Dragonfly 355 +3 skipped; Redis 358 +0 skipped.
PHPStan clean; no src/ changes.

Note: found 10 more unimplemented phpredis-compat accessor @method stubs
(getHost/getPort/isConnected/getAuth/getLastError/clearLastError/getDbNum/
getTimeout/getReadTimeout/getPersistentID) — same broken class as getMultiple;
reported for a src fix, no passing tests written for them.
The client's coroutine path — no callback + Revolt\EventLoop loaded => queueCommand
suspends the fiber and RETURNS the reply synchronously (suspenstion() + onMessage
resume) — was previously untested (all existing tests run callback mode).

- Added revolt/event-loop as a dev dependency.
- Added tests/Support/run-in-worker-coroutine.php: boots Workerman on its
  Revolt-backed Workerman\Events\Fiber driver so onWorkerStart runs inside a fiber
  and callback-less commands return their replies directly. Mirrors the fd-3
  protocol, coverage dump, and env forwarding of the callback worker.
- tests/Pest.php: added runInCoroutineWorker(); factored shared proc_open logic
  into runInWorkerScript(); runInWorker() behaviour unchanged.
- tests/Feature/CoroutineModeTest.php (4 cases): synchronous set/get/incr/del
  returns; scanAll full-keyset synchronous return; hScanAll/sScanAll/zScanAll
  coroutine aggregation; and the queueCommand guard that throws when a
  coroutine-mode command is issued while subscribe/monitor-locked.

Safety: the existing callback worker references no Revolt/EventLoop/Fiber symbols,
so class_exists(EventLoop::class, false) stays false there and the callback suite
is unchanged (verified: same counts/behaviour after adding the dep).

Client.php merged 75.79% -> 81.16%; total merged 77.93% -> 82.82%; floor stays 70.
Dragonfly 359 +3 skipped; Redis 362 +0 skipped. PHPStan clean; no src/ changes.
(composer.lock is gitignored project-wide; CI runs plain composer install so the
new require-dev resolves from composer.json.)
Added in-process unit + targeted feature tests for the genuinely-reachable
Client.php branches the integration suite couldn't hit cheaply:

- ClientShapingTier9Test.php (34): set/incr/decr second-form overloads
  (SETEX/INCRBY/DECRBY via func_get_args), sort/sortRo option flattening, xAdd
  empty-message guard + MAXLEN ~ shaping, dotted-dispatcher trailing-callable
  pops, formatter early-returns, shutdown _quitting flag.
- ClientSubscribeDispatchTest.php (12): the subscribe/pSubscribe/sSubscribe
  $new_cb dispatch arms — message/pmessage/smessage forwarding (exact arg order),
  error-bail, unknown-type diagnostic, unsubscribe-ack teardown, and the
  second-stream assertNoActiveStream throw.
- ReconnectPrependTest.php (1): dead-port connection failure reported through the
  connection callback (false + non-empty error()).

A t9Call() dynamic-dispatch helper routes the second-form overload calls (where an
int/callable rides in a $cb-typed slot) so PHPStan stays clean without weakening
the assertions or touching src.

Client.php merged 81.16% -> 92.32% (methods 65.85% -> 91.06%); total merged
82.82% -> 92.99%. Coverage floor ratcheted 70 -> 90.

The residual ~7% is documented in docs/TEST_COVERAGE_PLAN.md "Coverage close-out"
as genuinely impractical (socket fault injection, onClose auto-reconnect timing,
onMessage exception/reconnect, echo-Exception sinks, coroutine *ScanAll error
arms whose logic is proven in callback mode).

Dragonfly 406 +3 skipped; Redis 409 +0 skipped. PHPStan clean; no src/ changes.
Final documentation pass bringing user-facing docs to the completed state:

- README: updated the testing/coverage/CI/Compatibility sections to the final
  numbers — Dragonfly 406 passed/3 skipped, Redis 409 passed/0 skipped (Redis leg
  skip-free); total merged 92.99% (Client.php 92.32%, Protocols/Redis.php 100%,
  Exception 100%); coverage floor 90; added the subprocess-coverage-merge and
  Revolt coroutine-mode explanations and the documented-residual note. Removed the
  stale "FT skipped on Redis" text (un-gated in Group 5); the only divergences left
  are 3 Dragonfly behaviour-gated skips (auth-no-password x2, OBJECT unknown).
- CHANGELOG: added a build-out headline row to the Unreleased summary table.
- TEST_COVERAGE_PLAN: reconciled the close-out section to the final figures and
  flagged the §1 Group-0 baseline as interim.

No src/test/CI changes — docs only.
…-file leak

Two source/harness fixes surfaced during the test build-out:

1. 11 phpredis-compat @method stubs (getHost/getPort/getDbNum/getAuth/getTimeout/
   getReadTimeout/isConnected/getLastError/clearLastError/getPersistentID/
   getMultiple) had no implementation, so calling them sent a bogus uppercased
   verb (GETHOST, GETMULTIPLE, ...) that both engines reject. Replaced with real
   public methods: synchronous local accessors derived from client state
   (getTimeout/getReadTimeout read the actual connect_timeout/wait_timeout option
   keys; isConnected is null-safe), and getMultiple as a real MGET alias via
   queueCommand (byte-identical wire output to mGet; works in callback +
   coroutine modes). Also fixed the malformed rawCommand @method PHPDoc (named
   param after a variadic) and the same pattern across all other @method lines.
   Tests: ClientAccessorsTest.php (20) + GetMultipleTest.php (1).

2. The Feature-test subprocess runners leaked a wm-redis-test-*.{pid,log} pair
   per invocation (~25k files/run) because the bottom-of-file cleanup never ran
   before Workerman exits. Scoped the files into a wm-redis-tests/ subdir and
   added register_shutdown_function + in-handler unlink before each child exit(),
   with a start-of-run sweep in bin/run-coverage.sh. A full run now leaves zero
   residue (verified 0/0 across runs).

Client.php merged 92.32% -> 92.44% (methods 91.06% -> 91.79%); total 92.99% ->
93.09%; floor 90 holds. Dragonfly 427 +3 skipped; Redis 430 +0 skipped. PHPStan
clean (no baseline changes).
…rs, re-home global helpers to tests/helpers.php with PHP7 polyfills, dual phpunit configs, composer dep swap Pest->PHPUnit)
…rtions, global-namespace final classes, assertThrows for multi-throw cases); baseline 2 trivially-true narrowing artifacts from conversion
…al-namespace final classes extends RedisTestCase, heredoc bodies preserved); CoroutineModeTest self-skips below PHP 8.1 via coroutineSupported() guard. Green on both engines (dragonfly+redis); assertions preserved 695->696, no drops.
…s/Pest.php (Pest fully gone). Coverage holds at 93.09% line (>= floor 90); analyze clean; zero Pest references in tests/.
…across 4 test files. Arrow fns are 7.4+ and would parse-error on the 7.2/7.3 legs; the plan's floor is >=7.2. Suites green (Unit 145, scan 34 both engines).
…trips phpstan+revolt on 7.x -> PHPUnit 9 + Workerman 4; PHPStan gated to 8.x; 7.x uses phpunit9.xml.dist). Fix tests/helpers.php skip helpers to use cross-version Assert::markTestSkipped (SkippedWithMessageException doesn't exist in PHPUnit 9). Validated locally: Workerman v4 (281 non-coroutine green), PHPUnit 9 (285 Feature, 3 skip), both engines under PHPUnit 12 (430).
…E + TEST_COVERAGE_PLAN Pest->PHPUnit, document PHP 7.2-8.3 12-leg CI matrix, coroutine-skip rule, and lockfile policy; fix stale Pest references in RedisTestCase/helpers/merge-coverage comments.
…eturn' on the 3 subscribe-family assertThrows closures (sSubscribe() is void; assertThrows never uses the return). PHPStan clean.
…de 24

- ProtocolTest ConnectionInterface stub: mixed/bool|null -> untyped/?bool
  (7.1-safe, LSP-compatible with Workerman v4 untyped + v5 typed interfaces),
  the only PHP-8.0 syntax that broke the 7.x legs.
- phpunit/phpunit range gains ^8.5 so PHP 7.2 resolves PHPUnit 8.5 (9.6 needs
  >=7.3); 7.3/7.4 -> 9.6; 8.x -> 10-12.
- phpunit9.xml.dist drops <coverage> (testsuites+env only) so it validates under
  PHPUnit 8.5 and 9 alike; the 7.x legs don't run coverage.
- CI matrix adds 8.5: {7.2,7.3,7.4,8.1,8.2,8.3,8.5} x {dragonfly,redis}.
- checkout@v4->v6, cache@v5, FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to clear Node 20
  deprecation warnings.

Validated locally: stub green under v4 and v5; full 7.2-leg combo (PHPUnit 8.5 +
Workerman v4 + phpunit9.xml.dist) green (Unit 145, Feature 281 non-coroutine +
3 skips); 7.3/7.4 combo (PHPUnit 9 + v4) green; 8.x (v5 + PHPUnit 12) Unit 145 +
PHPStan clean.
The 18 module-command arg builders used PHP 7.4 array-literal spread
(['CMD', $k, ...$args, $cb]), which parse-errors on 7.2/7.3 — so the library
could not actually run on its advertised php: >=7.2 floor. Rewrote each to the
behavior-identical array_merge(['CMD', $k], $args, [$cb]) (all are numeric-
indexed command-arg lists, so the result is byte-identical). No behavior change:
both engines 430 green, coverage 93.09%, PHPStan clean.
…lls)

- De-flex all 285 flexible-heredoc closers across the 35 Feature files: move
  the closing 'PHP' marker to column 0 and the trailing ');'/', N);' to the
  next line. Flexible/indented heredoc syntax is PHP 7.3+; column-0 closers
  parse on 7.2 too. Snippet bodies unchanged; 430 green both engines.
- tests/helpers.php: build the proc_open command as a shell-escaped string
  (array form is PHP 7.4+; string works on 7.2/7.3 too), and add a guarded
  array_key_first() polyfill (the fn is PHP 7.3+; 6 call sites unchanged).

Verified on 8.3: 430 green (dragonfly 3-skip / redis 0-skip), coverage 93.09%,
PHPStan clean.
Test code intentionally diverges from production style (snake_case test_*
method names, long inline fixtures, reflection seams, heredoc worker snippets),
so Codacy's production-code rules flag it as noise. Coverage reporting is
unaffected. Clears the 'new issues' static-analysis gate on the PR.
Match the file's existing convention (\array_merge, \call_user_func, etc. —
namespaced code qualifies global functions). The array_merge backport had
introduced the only unqualified array_merge() calls in src/, which Codacy flags.
Merge the two adjacent note blockquotes with a '>'-prefixed blank line
(markdownlint MD028 — the only Codacy issue left after excluding tests/).
Also correct the now-stale runner detail: PHPUnit 8.5 on 7.2, 9.6 on 7.3/7.4,
12 on 8.1-8.5.
Convert Pest → PHPUnit + add PHP 7.2/7.3/7.4 CI legs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant